iT邦幫忙

2024 iThome 鐵人賽

DAY 16
2
Software Development

透過 nestjs 框架,讓 nodejs 系統維護度增加系列 第 29

nestjs 系統設計 - 活動訂票管理系統 - client code part 3

  • 分享至 

  • xImage
  •  

nestjs 系統設計 - 活動訂票管理系統 - client code part 3

目標

今天接續往下作控制元件。目標打算實做登入邏輯。

概念

為了能讓元件與邏輯具備各自獨立。這邊將使用透過 React 的 ContextProvider 的方式,來實做登入邏輯。使用 ReactNative 的 AsyncStorage 來儲存 access_token, refresh_token 與 User 的資訊。透過 useEffect 來從 AsyncStorage 更新當下 state 狀態。

這次的實做步驟,會先從一些資料存取的邏輯開始實做。然後,最後再套用在 Provider 元件對應的生命周期函數上。

實做登入邏輯

這部份邏輯會實做在 userService ,而型別會特別獨立出一個 types 資料夾存放

1. 安裝套件

這邊打算透過 axios 套件來作 http request 的實做

npm i -S axios

這邊會利用 axios 的 interceptor 來作一些基礎的設定,比如像是自動帶入 access_token 到 header 內。

2. 定義型別

  1. ApiResponse
    目前設計 api 的回傳值大致如下
export type ApiResponse<T> = {
  message: string;
  error?: string;
  data: T;
  statusCode?: number;
}

這邊使用 Generic Type 的宣告方式,來讓 data 的型別會透過帶入 T 來定義。保留回傳值得彈性。

  1. 其他用到的型別

透過 ApiResponse ,可以方便的定義出其他回應的型別 AuthResponse, RegisterResponse。

import { ApiResponse } from './api';

export enum UserRole {
  Admin = 'admin',
  Attendee = 'attendee'
}
export type AuthResponse = ApiResponse<{user: User, access_token: string, refresh_token: string}>;
export type RegisterResponse = ApiResponse<{id: string}>;
export type User = {
  id: number
  email: string
  role: UserRole
  createdAt: string
  updatedAt: string
}

3. 實做 service

  1. 定義基礎 api

這邊為了開發方便,所以先定義的 url 為本機的 url,正式環境則會另外更改。可以發現這邊會根據不同的手機作不同的設定。而透過 axios 的 interceptor ,可以設定一些預設的基礎邏輯,比如讀取的 data 回應的取法。

import AsyncStorage from '@react-native-async-storage/async-storage';
import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { Platform } from 'react-native';

const url = Platform.OS === 'android' ? 'http://10.0.2.2:3000':'http://127.0.0.1:3000';

const Api: AxiosInstance = axios.create({baseURL: url });
Api.interceptors.request.use(async config =>{
  const token = await AsyncStorage.getItem('access_token');
  if (token) config.headers.set('Authorization', token);
  return config;
});

Api.interceptors.response.use(
  async (res: AxiosResponse) => res.data,
  async (err: AxiosError) => Promise.reject(err)
);

export {Api};
  1. 實做 userService

這邊透過 Api 來實做, register 與 login 兩種邏輯。

import { AuthResponse, RegisterResponse } from '@/types/user';
import { Api } from './api';
type Credentials = {
  email: string
  password: string
}
async function login(credentials: Credentials): Promise<AuthResponse> {
  return Api.post('/auth/login', credentials);
}

async function register(credentials: Credentials): Promise<RegisterResponse> {
  return Api.post('/auth/register', credentials)
}

const userService = {
  login,
  register
}
export { userService };

4. 實做 Provider

import { userService } from '@/services/user';
import { AuthResponse, RegisterResponse, User } from '@/types/user';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { router } from 'expo-router';
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react';

interface AuthContextProps {
  isLoggedIn: boolean;
  isLoadingAuth: boolean;
  authenticate: (authMode: 'login'|'register', email: string, password: string) => Promise<void>;
  logout: VoidFunction;
  user: User | null;
}

const AuthContext = createContext({} as AuthContextProps);
// Create custom useAuth
export function useAuth() {
  return useContext(AuthContext);
}
export function AuthenticationProvider({ children }: PropsWithChildren) {
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [isLoadingAuth, setIsLoadingAuth] = useState(false);
  const [user, setUser] = useState<User|null>(null);
  useEffect(()=> {
    async function checkIfLoggedIn() {
      const [token, user] = await Promise.all([
        AsyncStorage.getItem('access_token'),
        AsyncStorage.getItem('user')]);
      if (token && user) {
        setIsLoggedIn(true);
        setUser(JSON.parse(user));
        router.replace("(authed)")
      } else {
        setIsLoggedIn(false);
      }
    }
    checkIfLoggedIn()
  }, []);
  async function authenticate(authMode: 'login'|'register', email: string, password: string):Promise<void> {
    try {
      setIsLoadingAuth(true);
      const response = await userService[authMode]({email, password});
      if (response) {
        if (authMode == 'login') {
          const authData: AuthResponse = response as unknown as AuthResponse;
          const {user, access_token, refresh_token} = authData.data;
          await Promise.all([
           AsyncStorage.setItem('access_token', access_token),
           AsyncStorage.setItem('refresh_token', refresh_token),
           AsyncStorage.setItem('user', JSON.stringify(user))
          ]);
          setUser(user);
          router.replace("(authed)")
		  setIsLoggedIn(true);
        } else {
          const registerResp = response as RegisterResponse;
          const { message } = registerResp;
          const { id } = registerResp.data;
          console.log({ message, id });
        }
      }
    } catch (error) {
      setIsLoggedIn(false);
    } finally {
      setIsLoadingAuth(false);
    }
  }
  async function logout() {
    setIsLoggedIn(false);
    await Promise.all([
      AsyncStorage.removeItem('access_token'),
      AsyncStorage.removeItem('refresh_token'),
      AsyncStorage.removeItem('user')
    ]);
    setUser(null);
  }
  return (
    <AuthContext.Provider
     value={{
      isLoggedIn,
      isLoadingAuth,
      authenticate,
      user,
      logout,
     }}
    >
      {children}
    </AuthContext.Provider>
  ) 
}

這邊先定義了一個 AuthContext Provider,並且設定了裡面的參數。透過了這個 Provider 就能把這段邏輯,讓其內部的 Component 來使用。

Provider 利用剛剛設定好的 userService 的功能來對 api 發網路請求,並且把處理邏輯寫在對應的 function 。透過 AsyncStorage 來作資料狀態的儲存。

套用到 authed layout

import { AuthenticationProvider } from '@/context/AuthContext';
import { Slot } from 'expo-router';
import { StatusBar } from 'expo-status-bar';

export default function Root() {
  return (
    <>
      <StatusBar style='dark' />
      <AuthenticationProvider>
        <Slot />
      </AuthenticationProvider>
    </>   
  );
}

透過 AuthenticationProvider,可以把 Authentication 邏輯套用在內部的元件上。

套用到 login 頁面

import { Button } from '@/components/Button';
import { Divider } from '@/components/Divider';
import { HStack } from '@/components/HStack';
import { Input } from '@/components/Input';
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { useAuth } from '@/context/AuthContext';
import { useState } from 'react';
import { KeyboardAvoidingView, ScrollView } from 'react-native';

export default function Login() {
  const {authenticate, isLoadingAuth } = useAuth();
  const [authMode, setAuthMode] = useState<'login'|'register'>('login')
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  async function onAuthenticate() {
    await authenticate(authMode, email, password)
  }
  function onToggleAuthMode() {
    setAuthMode(authMode === 'login'? 'register':'login');
  }
  return (
    <KeyboardAvoidingView behavior='padding' style={{ flex: 1 }}>
      <ScrollView contentContainerStyle={{flex: 1}}>
        <VStack flex={1} justifyContent='center' alignItems='center' p={40} gap={40}>
          <HStack gap={10}>
            <Text fontSize={30} bold mb={20}>Ticket Booking</Text>
            <TabBarIcon name='ticket' size={50}/>
          </HStack>
          <VStack w={'100%'} gap={30}>
            <VStack gap={5}>
              <Text ml={10} fontSize={14} color='gray'>Email</Text>
              <Input 
                value={email}
                onChangeText={setEmail}
                placeholder='Email'
                placeholderTextColor='darkgray'
                autoCapitalize='none'
                autoCorrect={false}
                h={48}
                p={14}
              />
            </VStack>
            <VStack gap={5}>
              <Text ml={10} fontSize={14} color='gray'>Password</Text>
              <Input
                secureTextEntry
                value={password}
                onChangeText={setPassword}
                placeholder='Password'
                placeholderTextColor='darkgray'
                autoCapitalize='none'
                autoCorrect={false}
                h={48}
                p={14}
              />
            </VStack>
          </VStack>
          <Button
            w={'100%'}
            isLoading={isLoadingAuth}
            onPress={onAuthenticate}
          >
            {authMode === 'login'? 'Login': 'Register'}
          </Button>
          <Divider w={'90%'}/>
          <Text onPress={onToggleAuthMode} fontSize={16} underline>
            {authMode === 'login'? 'Register new account': 'Login to account'}
          </Text>
        </VStack>
      </ScrollView>
    </KeyboardAvoidingView>
  );
}

測試註冊一位使用,然後登入

  1. 註冊
    image

透過 inspector 看到註冊成功的訊息
image

  1. 登入

使用剛才的資訊作登入
image

登入成功的結果
image
透過 inspector 讀取到的 log
image

結論

實做 Mobile app 的最複雜之處在於狀態的處理,特別是牽涉到元件生命週期的部份。到這裡剩下了最後關鍵處理 Ticket 的部份了。這部份會在下一篇作詳細處理。


上一篇
nestjs 系統設計 - 活動訂票管理系統 - client code part 2
下一篇
nestjs 系統設計 - 活動訂票管理系統 - client code part 4
系列文
透過 nestjs 框架,讓 nodejs 系統維護度增加31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言